/*
 * Copyright (C) 2013 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */

package com.android.talkback;

import android.content.Context;

import android.annotation.TargetApi;
import android.content.SharedPreferences;
import android.os.Build;
import android.view.KeyEvent;

import com.android.talkback.keyboard.DefaultKeyComboModel;
import com.android.talkback.keyboard.KeyComboModel;
import com.android.talkback.keyboard.KeyComboModelApp;
import com.android.utils.SharedPreferencesUtils;
import com.google.android.marvin.talkback.TalkBackService;

import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Manages state related to detecting key combinations.
 *
 * TODO: move KeyComboManager under package talkback.keyboard.
 */
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
public class KeyComboManager implements TalkBackService.KeyEventListener,
        TalkBackService.ServiceStateListener {

    public static final int MIN_API_LEVEL = Build.VERSION_CODES.JELLY_BEAN_MR2;

    public static final int NO_MATCH = -1;
    public static final int PARTIAL_MATCH = 1;
    public static final int EXACT_MATCH = 2;

    public static final int ACTION_UNKNOWN = -1;
    public static final int ACTION_NAVIGATE_NEXT = 1;
    public static final int ACTION_NAVIGATE_PREVIOUS = 2;
    public static final int ACTION_NAVIGATE_FIRST = 3;
    public static final int ACTION_NAVIGATE_LAST = 4;
    public static final int ACTION_PERFORM_CLICK = 5;
    public static final int ACTION_BACK = 6;
    public static final int ACTION_HOME = 7;
    public static final int ACTION_RECENTS = 8;
    public static final int ACTION_NOTIFICATION = 9;
    public static final int ACTION_SUSPEND_OR_RESUME = 10;
    public static final int ACTION_GRANULARITY_INCREASE = 11;
    public static final int ACTION_GRANULARITY_DECREASE = 12;
    public static final int ACTION_READ_FROM_TOP = 13;
    public static final int ACTION_READ_FROM_NEXT_ITEM = 14;
    public static final int ACTION_TOGGLE_SEARCH = 15;
    public static final int ACTION_LOCAL_CONTEXT_MENU = 16;
    public static final int ACTION_GLOBAL_CONTEXT_MENU = 17;
    public static final int ACTION_NAVIGATE_UP = 18;
    public static final int ACTION_NAVIGATE_DOWN = 19;
    public static final int ACTION_NAVIGATE_NEXT_WORD = 20;
    public static final int ACTION_NAVIGATE_PREVIOUS_WORD = 21;
    public static final int ACTION_NAVIGATE_NEXT_CHARACTER = 22;
    public static final int ACTION_NAVIGATE_PREVIOUS_CHARACTER = 23;
    public static final int ACTION_PERFORM_LONG_CLICK = 24;
    public static final int ACTION_NAVIGATE_NEXT_HEADING = 25;
    public static final int ACTION_NAVIGATE_PREVIOUS_HEADING = 26;
    public static final int ACTION_NAVIGATE_NEXT_BUTTON = 27;
    public static final int ACTION_NAVIGATE_PREVIOUS_BUTTON = 28;
    public static final int ACTION_NAVIGATE_NEXT_CHECKBOX = 29;
    public static final int ACTION_NAVIGATE_PREVIOUS_CHECKBOX = 30;
    public static final int ACTION_NAVIGATE_NEXT_ARIA_LANDMARK = 31;
    public static final int ACTION_NAVIGATE_PREVIOUS_ARIA_LANDMARK = 32;
    public static final int ACTION_NAVIGATE_NEXT_EDIT_FIELD = 33;
    public static final int ACTION_NAVIGATE_PREVIOUS_EDIT_FIELD = 34;
    public static final int ACTION_NAVIGATE_NEXT_FOCUSABLE_ITEM = 35;
    public static final int ACTION_NAVIGATE_PREVIOUS_FOCUSABLE_ITEM = 36;
    public static final int ACTION_NAVIGATE_NEXT_HEADING_1 = 37;
    public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_1 = 38;
    public static final int ACTION_NAVIGATE_NEXT_HEADING_2 = 39;
    public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_2 = 40;
    public static final int ACTION_NAVIGATE_NEXT_HEADING_3 = 41;
    public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_3 = 42;
    public static final int ACTION_NAVIGATE_NEXT_HEADING_4 = 43;
    public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_4 = 44;
    public static final int ACTION_NAVIGATE_NEXT_HEADING_5 = 45;
    public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_5 = 46;
    public static final int ACTION_NAVIGATE_NEXT_HEADING_6 = 47;
    public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_6 = 48;
    public static final int ACTION_NAVIGATE_NEXT_LINK = 49;
    public static final int ACTION_NAVIGATE_PREVIOUS_LINK = 50;
    public static final int ACTION_NAVIGATE_NEXT_CONTROL = 51;
    public static final int ACTION_NAVIGATE_PREVIOUS_CONTROL = 52;
    public static final int ACTION_NAVIGATE_NEXT_GRAPHIC = 53;
    public static final int ACTION_NAVIGATE_PREVIOUS_GRAPHIC = 54;
    public static final int ACTION_NAVIGATE_NEXT_LIST_ITEM = 55;
    public static final int ACTION_NAVIGATE_PREVIOUS_LIST_ITEM = 56;
    public static final int ACTION_NAVIGATE_NEXT_LIST = 57;
    public static final int ACTION_NAVIGATE_PREVIOUS_LIST = 58;
    public static final int ACTION_NAVIGATE_NEXT_TABLE = 59;
    public static final int ACTION_NAVIGATE_PREVIOUS_TABLE = 60;
    public static final int ACTION_NAVIGATE_NEXT_COMBOBOX = 61;
    public static final int ACTION_NAVIGATE_PREVIOUS_COMBOBOX = 62;
    public static final int ACTION_NAVIGATE_NEXT_WINDOW = 63;
    public static final int ACTION_NAVIGATE_PREVIOUS_WINDOW = 64;
    public static final int ACTION_OPEN_MANAGE_KEYBOARD_SHORTCUTS = 65;
    public static final int ACTION_OPEN_TALKBACK_SETTINGS = 66;

    private static final int KEY_EVENT_MODIFIER_MASK = KeyEvent.META_SHIFT_ON |
            KeyEvent.META_CTRL_ON | KeyEvent.META_ALT_ON | KeyEvent.META_META_ON;

    static final String CONCATINATION_STR = " + ";
    private static final String KEYCODE_PREFIX = "KEYCODE_";

    /**
     * When user has pressed same key twice less than this interval, we handle them as double tap.
     */
    private static final long TIME_TO_DETECT_DOUBLE_TAP = 1000; // ms

    private static final boolean IS_IN_ARC = TalkBackService.isInArc();

    private static final int DEFAULT_KEYMAP = IS_IN_ARC ? R.string.default_keymap_entry_value :
            R.string.classic_keymap_entry_value;

    /**
     * Returns kecComboCode that represent keyEvent.
     */
    public static long getKeyComboCode(KeyEvent keyEvent) {
        if (keyEvent == null) {
            return KeyComboModel.KEY_COMBO_CODE_UNASSIGNED;
        }

        int modifier = keyEvent.getModifiers() & KEY_EVENT_MODIFIER_MASK;
        return getKeyComboCode(modifier, getConvertedKeyCode(keyEvent));
    }

    /**
     * Returns key combo code which is combination of modifier and keycode.
     * @param modifier
     * @param keycode
     * @return
     */
    public static long getKeyComboCode(int modifier, int keycode) {
        return (((long) modifier) << 32) + keycode;
    }

    /**
     * Returns modifier part of key combo code.
     * @param keyComboCode
     * @return
     */
    public static int getModifier(long keyComboCode) {
        return (int) (keyComboCode >> 32);
    }

    /**
     * Returns key code part of key combo code.
     * @param keyComboCode
     * @return
     */
    public static int getKeyCode(long keyComboCode) {
        return (int) (keyComboCode);
    }

    /**
     * Returns converted key code. This method converts the following key events.
     * - Convert KEYCODE_HOME with meta to KEYCODE_ENTER.
     * - Convert KEYCODE_BACK with meta to KEYCODE_DEL.
     * @param event Key event to be converted.
     * @return Converted key code.
     */
    static int getConvertedKeyCode(KeyEvent event) {
        // We care only when meta key is pressed with.
        if ((event.getModifiers() & KeyEvent.META_META_ON) == 0) {
            return event.getKeyCode();
        }

        if (event.getKeyCode() == KeyEvent.KEYCODE_HOME) {
            return KeyEvent.KEYCODE_ENTER;
        } else if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
            return KeyEvent.KEYCODE_DEL;
        } else {
            return event.getKeyCode();
        }
    }

    /** Whether the user performed a combo during the current interaction. */
    private boolean mPerformedCombo;

    /** Whether the user may be performing a combo and we should intercept keys. */
    private boolean mHasPartialMatch;

    private Set<Integer> mCurrentKeysDown = new HashSet<>();
    private Set<Integer> mPassedKeys = new HashSet<>();

    private long mCurrentKeyComboCode = KeyComboModel.KEY_COMBO_CODE_UNASSIGNED;
    private long mCurrentKeyComboTime = 0;
    private long mPreviousKeyComboCode = KeyComboModel.KEY_COMBO_CODE_UNASSIGNED;
    private long mPreviousKeyComboTime = 0;

    /** The listener that receives callbacks when a combo is recognized. */
    private final List<KeyComboListener> mListeners = new LinkedList<>();

    private Context mContext;
    private boolean mMatchKeyCombo = true;
    private KeyComboModel mKeyComboModel;
    private TalkBackServiceStateHelper mServiceStateHelper;
    private KeyboardShortcutDialogPreference mKeyboardShortcutDialogPreference;

    public static KeyComboManager create(Context context) {
        if (Build.VERSION.SDK_INT >= MIN_API_LEVEL) {
            return new KeyComboManager(context);
        }

        return null;
    }

    private KeyComboManager(Context context) {
        mContext = context;
        mKeyComboModel = createKeyComboModelFor(getKeymap());
        mServiceStateHelper = new TalkBackServiceStateHelperApp();

        initializeDefaultPreferenceValues();
    }

    /**
     * Store default values in preferences to show them in preferences UI.
     */
    private void initializeDefaultPreferenceValues() {
        SharedPreferences preferences = SharedPreferencesUtils.getSharedPreferences(mContext);
        if (preferences.contains(mContext.getString(R.string.pref_select_keymap_key))) {
            return;
        }

        preferences.edit().putString(mContext.getString(R.string.pref_select_keymap_key),
                mContext.getString(DEFAULT_KEYMAP)).apply();
    }

    /**
     * Sets KeyboardShortcutDialogPreference for key events. If it's set, it can listen and consume
     * key events before KeyComboManager does. Sets null to remove current one.
     */
    public void setKeyboardShortcutDialogPreferenceForKeyEvents(
            KeyboardShortcutDialogPreference keyboardShortcutDialogPreference) {
        mKeyboardShortcutDialogPreference = keyboardShortcutDialogPreference;
    }

    /**
     * Returns keymap by reading preference.
     */
    public String getKeymap() {
        SharedPreferences preferences = SharedPreferencesUtils.getSharedPreferences(mContext);
        return preferences.getString(mContext.getString(R.string.pref_select_keymap_key),
                mContext.getString(DEFAULT_KEYMAP));
    }

    /**
     * Creates key combo model for specified keymap.
     * @param keymap Keymap.
     * @return Key combo model. null will be returned if keymap is invalid.
     */
    public KeyComboModel createKeyComboModelFor(String keymap) {
        if (keymap.equals(mContext.getString(R.string.classic_keymap_entry_value))) {
            return new KeyComboModelApp(mContext);
        } else if (keymap.equals(mContext.getString(R.string.default_keymap_entry_value))) {
            return new DefaultKeyComboModel(mContext);
        }
        return null;
    }

    /**
     * Sets TalkBackServiceStateHelper for testing.
     */
    void setTalkBackServiceStateHelperForTesting(
            TalkBackServiceStateHelper serviceStateHelper) {
        mServiceStateHelper = serviceStateHelper;
    }

    /**
     * Returns key combo model.
     * @return
     */
    public KeyComboModel getKeyComboModel() {
        return mKeyComboModel;
    }

    /**
     * Sets key combo model.
     * TODO: replace this method with setKeymap.
     */
    public void setKeyComboModel(KeyComboModel keyComboModel) {
        mKeyComboModel = keyComboModel;
    }

    /**
     * Returns corresponding action id to key. If invalid value is passed as key, ACTION_UNKNOWN
     * will be returned.
     * @param key
     * @return
     */
    private int getActionIdFromKey(String key) {
        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next))) {
            return ACTION_NAVIGATE_NEXT;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous))) {
            return ACTION_NAVIGATE_PREVIOUS;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_first))) {
            return ACTION_NAVIGATE_FIRST;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_last))) {
            return ACTION_NAVIGATE_LAST;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_perform_click))) {
            return ACTION_PERFORM_CLICK;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_back))) {
            return ACTION_BACK;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_home))) {
            return ACTION_HOME;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_recents))) {
            return ACTION_RECENTS;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_notifications))) {
            return ACTION_NOTIFICATION;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_suspend))) {
            return ACTION_SUSPEND_OR_RESUME;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_granularity_increase))) {
            return ACTION_GRANULARITY_INCREASE;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_granularity_decrease))) {
            return ACTION_GRANULARITY_DECREASE;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_read_from_top))) {
            return ACTION_READ_FROM_TOP;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_read_from_next_item))) {
            return ACTION_READ_FROM_NEXT_ITEM;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_toggle_search))) {
            return ACTION_TOGGLE_SEARCH;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_local_context_menu))) {
            return ACTION_LOCAL_CONTEXT_MENU;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_global_context_menu))) {
            return ACTION_GLOBAL_CONTEXT_MENU;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_up))) {
            return ACTION_NAVIGATE_UP;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_down))) {
            return ACTION_NAVIGATE_DOWN;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_word))) {
            return ACTION_NAVIGATE_NEXT_WORD;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_word))) {
            return ACTION_NAVIGATE_PREVIOUS_WORD;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_character))) {
            return ACTION_NAVIGATE_NEXT_CHARACTER;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_character))) {
            return ACTION_NAVIGATE_PREVIOUS_CHARACTER;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_perform_long_click))) {
            return ACTION_PERFORM_LONG_CLICK;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading))) {
            return ACTION_NAVIGATE_NEXT_HEADING;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading))) {
            return ACTION_NAVIGATE_PREVIOUS_HEADING;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_button))) {
            return ACTION_NAVIGATE_NEXT_BUTTON;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_button))) {
            return ACTION_NAVIGATE_PREVIOUS_BUTTON;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_checkbox))) {
            return ACTION_NAVIGATE_NEXT_CHECKBOX;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_checkbox))) {
            return ACTION_NAVIGATE_PREVIOUS_CHECKBOX;
        }

        if (key.equals(mContext.getString(
                R.string.keycombo_shortcut_navigate_next_aria_landmark))) {
            return ACTION_NAVIGATE_NEXT_ARIA_LANDMARK;
        }

        if (key.equals(mContext.getString(
                R.string.keycombo_shortcut_navigate_previous_aria_landmark))) {
            return ACTION_NAVIGATE_PREVIOUS_ARIA_LANDMARK;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_edit_field))) {
            return ACTION_NAVIGATE_NEXT_EDIT_FIELD;
        }

        if (key.equals(mContext.getString(
                R.string.keycombo_shortcut_navigate_previous_edit_field))) {
            return ACTION_NAVIGATE_PREVIOUS_EDIT_FIELD;
        }

        if (key.equals(mContext.getString(
                R.string.keycombo_shortcut_navigate_next_focusable_item))) {
            return ACTION_NAVIGATE_NEXT_FOCUSABLE_ITEM;
        }

        if (key.equals(mContext.getString(
                R.string.keycombo_shortcut_navigate_previous_focusable_item))) {
            return ACTION_NAVIGATE_PREVIOUS_FOCUSABLE_ITEM;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_1))) {
            return ACTION_NAVIGATE_NEXT_HEADING_1;
        }

        if (key.equals(mContext.getString(
                R.string.keycombo_shortcut_navigate_previous_heading_1))) {
            return ACTION_NAVIGATE_PREVIOUS_HEADING_1;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_2))) {
            return ACTION_NAVIGATE_NEXT_HEADING_2;
        }

        if (key.equals(mContext.getString(
                R.string.keycombo_shortcut_navigate_previous_heading_2))) {
            return ACTION_NAVIGATE_PREVIOUS_HEADING_2;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_3))) {
            return ACTION_NAVIGATE_NEXT_HEADING_3;
        }

        if (key.equals(mContext.getString(
                R.string.keycombo_shortcut_navigate_previous_heading_3))) {
            return ACTION_NAVIGATE_PREVIOUS_HEADING_3;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_4))) {
            return ACTION_NAVIGATE_NEXT_HEADING_4;
        }

        if (key.equals(mContext.getString(
                R.string.keycombo_shortcut_navigate_previous_heading_4))) {
            return ACTION_NAVIGATE_PREVIOUS_HEADING_4;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_5))) {
            return ACTION_NAVIGATE_NEXT_HEADING_5;
        }

        if (key.equals(mContext.getString(
                R.string.keycombo_shortcut_navigate_previous_heading_5))) {
            return ACTION_NAVIGATE_PREVIOUS_HEADING_5;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_6))) {
            return ACTION_NAVIGATE_NEXT_HEADING_6;
        }

        if (key.equals(mContext.getString(
                R.string.keycombo_shortcut_navigate_previous_heading_6))) {
            return ACTION_NAVIGATE_PREVIOUS_HEADING_6;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_link))) {
            return ACTION_NAVIGATE_NEXT_LINK;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_link))) {
            return ACTION_NAVIGATE_PREVIOUS_LINK;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_control))) {
            return ACTION_NAVIGATE_NEXT_CONTROL;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_control))) {
            return ACTION_NAVIGATE_PREVIOUS_CONTROL;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_graphic))) {
            return ACTION_NAVIGATE_NEXT_GRAPHIC;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_graphic))) {
            return ACTION_NAVIGATE_PREVIOUS_GRAPHIC;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_list_item))) {
            return ACTION_NAVIGATE_NEXT_LIST_ITEM;
        }

        if (key.equals(mContext.getString(
                R.string.keycombo_shortcut_navigate_previous_list_item))) {
            return ACTION_NAVIGATE_PREVIOUS_LIST_ITEM;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_list))) {
            return ACTION_NAVIGATE_NEXT_LIST;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_list))) {
            return ACTION_NAVIGATE_PREVIOUS_LIST;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_table))) {
            return ACTION_NAVIGATE_NEXT_TABLE;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_table))) {
            return ACTION_NAVIGATE_PREVIOUS_TABLE;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_combobox))) {
            return ACTION_NAVIGATE_NEXT_COMBOBOX;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_combobox))) {
            return ACTION_NAVIGATE_PREVIOUS_COMBOBOX;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_window))) {
            return ACTION_NAVIGATE_NEXT_WINDOW;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_window))) {
            return ACTION_NAVIGATE_PREVIOUS_WINDOW;
        }

        if (key.equals(mContext.getString(
                R.string.keycombo_shortcut_open_manage_keyboard_shortcuts))) {
            return ACTION_OPEN_MANAGE_KEYBOARD_SHORTCUTS;
        }

        if (key.equals(mContext.getString(R.string.keycombo_shortcut_open_talkback_settings))) {
            return ACTION_OPEN_TALKBACK_SETTINGS;
        }

        return ACTION_UNKNOWN;
    }

    /**
     * Returns true if key combination of the key should be always processed.
     * @param key
     * @return
     */
    private boolean alwaysProcessCombo(String key) {
        return key.equals(mContext.getString(R.string.keycombo_shortcut_global_suspend));
    }

    /**
     * Sets the listener that receives callbacks when the user performs key
     * combinations.
     *
     * @param listener The listener that receives callbacks.
     */
    public void addListener(KeyComboListener listener) {
        mListeners.add(listener);
    }

    /**
     * Set whether to process keycombo
     */
    public void setMatchKeyCombo(boolean value) {
        mMatchKeyCombo = value;
    }

    /**
     * Returns user friendly string representations of key combo code
     */
    public String getKeyComboStringRepresentation(long keyComboCode) {
        if (keyComboCode == KeyComboModel.KEY_COMBO_CODE_UNASSIGNED) {
            return mContext.getString(R.string.keycombo_unassigned);
        }

        int triggerModifier = mKeyComboModel.getTriggerModifier();
        int modifier = getModifier(keyComboCode);
        int modifierWithoutTriggerModifier = modifier & ~triggerModifier;
        int keyCode = getKeyCode(keyComboCode);

        StringBuilder sb = new StringBuilder();

        // Append trigger modifier if key combo code contains it.
        if ((triggerModifier & modifier) != 0) {
            appendModifiers(triggerModifier, sb);
        }

        // Append modifier except trigger modifier.
        appendModifiers(modifierWithoutTriggerModifier, sb);

        // Append key code.
        if (keyCode > 0 && !KeyEvent.isModifierKey(keyCode)) {
            appendPlusSignIfNotEmpty(sb);

            switch (keyCode) {
                case KeyEvent.KEYCODE_DPAD_RIGHT:
                    sb.append(mContext.getString(R.string.keycombo_key_arrow_right));
                    break;
                case KeyEvent.KEYCODE_DPAD_LEFT:
                    sb.append(mContext.getString(R.string.keycombo_key_arrow_left));
                    break;
                case KeyEvent.KEYCODE_DPAD_UP:
                    sb.append(mContext.getString(R.string.keycombo_key_arrow_up));
                    break;
                case KeyEvent.KEYCODE_DPAD_DOWN:
                    sb.append(mContext.getString(R.string.keycombo_key_arrow_down));
                    break;
                default:
                    String keyCodeString = KeyEvent.keyCodeToString(keyCode);
                    if (keyCodeString != null) {
                        String keyCodeNoPrefix;
                        if (keyCodeString.startsWith(KEYCODE_PREFIX)) {
                            keyCodeNoPrefix = keyCodeString.substring(KEYCODE_PREFIX.length());
                        } else {
                            keyCodeNoPrefix = keyCodeString;
                        }
                        sb.append(keyCodeNoPrefix.replace('_', ' '));
                    }
                    break;
            }
        }

        return sb.toString();
    }

    /**
     * Appends modifier.
     */
    private void appendModifiers(int modifier, StringBuilder sb) {
        appendModifier(modifier, KeyEvent.META_ALT_ON,
                mContext.getString(R.string.keycombo_key_modifier_alt), sb);
        appendModifier(modifier, KeyEvent.META_SHIFT_ON,
                mContext.getString(R.string.keycombo_key_modifier_shift), sb);
        appendModifier(modifier, KeyEvent.META_CTRL_ON,
                mContext.getString(R.string.keycombo_key_modifier_ctrl), sb);
        appendModifier(modifier, KeyEvent.META_META_ON,
                mContext.getString(R.string.keycombo_key_modifier_meta), sb);
    }

    /**
     * Appends string representation of target modifier if modifier contains it.
     */
    private void appendModifier(int modifier, int targetModifier, String stringRepresentation,
            StringBuilder sb) {
        if ((modifier & targetModifier) != 0) {
            appendPlusSignIfNotEmpty(sb);
            sb.append(stringRepresentation);
        }
    }

    private void appendPlusSignIfNotEmpty(StringBuilder sb) {
        if (sb.length() > 0) {
            sb.append(CONCATINATION_STR);
        }
    }

    /**
     * Handles incoming key events. May intercept keys if the user seems to be
     * performing a key combo.
     *
     * @param event The key event.
     * @return {@code true} if the key was intercepted.
     */
    @Override
    public boolean onKeyEvent(KeyEvent event) {
        if (mKeyboardShortcutDialogPreference != null) {
            if (mKeyboardShortcutDialogPreference.onKeyEventFromKeyComboManager(event)) {
                return true;
            }
        }

        if (!mHasPartialMatch && !mPerformedCombo &&
                (!mMatchKeyCombo || mListeners.isEmpty())) {
            return false;
        }

        switch (event.getAction()) {
            case KeyEvent.ACTION_DOWN:
                return onKeyDown(event);
            case KeyEvent.ACTION_MULTIPLE:
                return mHasPartialMatch;
            case KeyEvent.ACTION_UP:
                return onKeyUp(event);
            default:
                return false;
        }
    }

    @Override
    public boolean processWhenServiceSuspended() {
        return true;
    }

    private KeyEvent convertKeyEventInArc(KeyEvent event) {
        switch (event.getKeyCode()) {
            case KeyEvent.KEYCODE_HOME:
            case KeyEvent.KEYCODE_BACK:
                // In Arc, Search + X is sent as KEYCODE_X with META_META_ON in Android. Android
                // converts META_META_ON + KEYCODE_ENTER and META_META_ON + KEYCODE_DEL to
                // KEYCODE_HOME and KEYCODE_BACK without META_META_ON. We add META_META_ON to this
                // key event to satisfy trigger modifier condition. We don't need to do this in
                // non-Arc since Search + X is usually sent as KEYCODE_X with META_META_ON and
                // META_META_LEFT_ON or META_META_RIGHT_ON.
                return new KeyEvent(event.getDownTime(), event.getEventTime(), event.getAction(),
                        event.getKeyCode(), event.getRepeatCount(),
                        event.getMetaState() | KeyEvent.META_META_ON);
            default:
                return event;
        }
    }

    private boolean onKeyDown(KeyEvent event) {
        if (IS_IN_ARC) {
            event = convertKeyEventInArc(event);
        }

        mCurrentKeysDown.add(event.getKeyCode());
        mCurrentKeyComboCode = getKeyComboCode(event);
        mCurrentKeyComboTime = event.getDownTime();

        // Check modifier.
        int triggerModifier = mKeyComboModel.getTriggerModifier();
        boolean hasModifier = triggerModifier != KeyComboModel.NO_MODIFIER;
        if (hasModifier && (triggerModifier & event.getModifiers()) != triggerModifier) {
            // Do nothing if condition of modifier is not met.
            mPassedKeys.addAll(mCurrentKeysDown);
            return false;
        }

        boolean isServiceActive = mServiceStateHelper.isServiceActive();

        // If the current set of keys is a partial combo, consume the event.
        mHasPartialMatch = false;

        for (Map.Entry<String, Long> entry : mKeyComboModel.getKeyComboCodeMap().entrySet()) {
            if (!isServiceActive && !alwaysProcessCombo(entry.getKey())) {
                continue;
            }

            final int match = matchKeyEventWith(event, triggerModifier, entry.getValue());
            if (match == EXACT_MATCH) {
                for (KeyComboListener listener : mListeners) {
                    if (listener.onComboPerformed(getActionIdFromKey(entry.getKey()))) {
                        mPerformedCombo = true;
                        return true;
                    }
                }
            }

            if (match == PARTIAL_MATCH) {
                mHasPartialMatch = true;
            }
        }

        // Do not handle key event if user has pressed search key (meta key) twice to open search
        // app.
        if (hasModifier && triggerModifier == KeyEvent.META_META_ON) {
            if (mPreviousKeyComboCode == mCurrentKeyComboCode &&
                mCurrentKeyComboTime - mPreviousKeyComboTime < TIME_TO_DETECT_DOUBLE_TAP &&
                (mCurrentKeyComboCode == KeyComboManager.getKeyComboCode(
                        KeyEvent.META_META_ON, KeyEvent.KEYCODE_META_RIGHT) ||
                 mCurrentKeyComboCode == KeyComboManager.getKeyComboCode(
                        KeyEvent.META_META_ON, KeyEvent.KEYCODE_META_LEFT))) {
                // Set KEY_COMBO_CODE_UNASSIGNED not to open search app again with following search
                // key event.
                mCurrentKeyComboCode = KeyComboModel.KEY_COMBO_CODE_UNASSIGNED;
                mPassedKeys.addAll(mCurrentKeysDown);
                return false;
            }
        }

        if (!mHasPartialMatch) {
            mPassedKeys.addAll(mCurrentKeysDown);
        }
        return mHasPartialMatch;
    }

    private int matchKeyEventWith(KeyEvent event, int triggerModifier, long keyComboCode) {
        int keyCode = getConvertedKeyCode(event);
        int metaState = event.getModifiers() & KEY_EVENT_MODIFIER_MASK;

        int targetKeyCode = getKeyCode(keyComboCode);
        int targetMetaState = getModifier(keyComboCode) | triggerModifier;

        // Handle exact matches first.
        if (metaState == targetMetaState && keyCode == targetKeyCode) {
            return EXACT_MATCH;
        }

        if (targetMetaState != 0 && metaState == 0) {
            return NO_MATCH;
        }

        // Otherwise, all modifiers must be down.
        if (KeyEvent.isModifierKey(keyCode) && targetMetaState != 0 &&
                (targetMetaState & metaState) != 0) {
            // Partial match.
            return PARTIAL_MATCH;
        }

        // No match.
        return NO_MATCH;
    }

    private boolean onKeyUp(KeyEvent event) {
        if (IS_IN_ARC) {
            event = convertKeyEventInArc(event);
        }

        mCurrentKeysDown.remove(event.getKeyCode());
        boolean passed = mPassedKeys.remove(event.getKeyCode());

        if (mCurrentKeysDown.isEmpty()) {
            // The interaction is over, reset the state.
            mPerformedCombo = false;
            mHasPartialMatch = false;
            mPreviousKeyComboCode = mCurrentKeyComboCode;
            mPreviousKeyComboTime = mCurrentKeyComboTime;
            mCurrentKeyComboCode = KeyComboModel.KEY_COMBO_CODE_UNASSIGNED;
            mCurrentKeyComboTime = 0;
            mPassedKeys.clear();
        }

        return !passed;
    }

    @Override
    public void onServiceStateChanged(int newState) {
        // Unfortunately, key events are lost when the TalkBackService becomes active. If a key-down
        // occurs that triggers TalkBack to resume, the corresponding key-up event will not be
        // sent, causing the partially-matched key history to become inconsistent.
        // The following method will cause the key history to be reset.
        setMatchKeyCombo(mMatchKeyCombo);
    }

    public interface KeyComboListener {
        public boolean onComboPerformed(int id);
    }

    public interface TalkBackServiceStateHelper {
        public boolean isServiceActive();
    }

    private class TalkBackServiceStateHelperApp implements TalkBackServiceStateHelper {
        @Override
        public boolean isServiceActive() {
            return TalkBackService.isServiceActive();
        }
    }
}
